-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Fix GH-16649: Avoid UAF when using array_splice #19399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: PHP-8.3
Are you sure you want to change the base?
Conversation
The problem also happens when the array is converted from packed to hash: class C {
function __destruct() {
global $arr;
// array is converted from packed to hash. in_hash->arPacked becomes invalid.
$arr["str"] = 0;
}
}
$arr = ["1", new C, "2"];
array_splice($arr, 1, 2); Or if the array is released entirely: class C {
function __destruct() {
global $arr;
$arr = null;
}
}
$arr = ["1", new C, "2"];
array_splice($arr, 1, 2); An alternative fix would be to increase the refcount of |
I tried by using GC_ADDREF, but then calling the underlying |
I missed that the function is also mutating
The first solution would be faster, but is like more complex. |
Or instead of making it complicated, use a little hack: diff --git a/ext/standard/array.c b/ext/standard/array.c
index a2a0459ec3b..08b0f5253d6 100644
--- a/ext/standard/array.c
+++ b/ext/standard/array.c
@@ -3214,6 +3214,10 @@ static void php_splice(HashTable *in_hash, zend_long offset, zend_long length, H
zval *entry; /* Hash entry */
uint32_t iter_pos = zend_hash_iterators_lower_pos(in_hash, 0);
+ /* Enforce separation on running user code */
+ GC_ADDREF(in_hash);
+ HT_ALLOW_COW_VIOLATION(in_hash); /* will be reset down below when setting the flags for in_hash */
+
/* Get number of entries in the input hash */
num_in = zend_hash_num_elements(in_hash);
@@ -3372,18 +3376,23 @@ static void php_splice(HashTable *in_hash, zend_long offset, zend_long length, H
HT_SET_ITERATORS_COUNT(&out_hash, HT_ITERATORS_COUNT(in_hash));
HT_SET_ITERATORS_COUNT(in_hash, 0);
in_hash->pDestructor = NULL;
- zend_hash_destroy(in_hash);
-
- HT_FLAGS(in_hash) = HT_FLAGS(&out_hash);
- in_hash->nTableSize = out_hash.nTableSize;
- in_hash->nTableMask = out_hash.nTableMask;
- in_hash->nNumUsed = out_hash.nNumUsed;
- in_hash->nNumOfElements = out_hash.nNumOfElements;
- in_hash->nNextFreeElement = out_hash.nNextFreeElement;
- in_hash->arData = out_hash.arData;
- in_hash->pDestructor = out_hash.pDestructor;
-
- zend_hash_internal_pointer_reset(in_hash);
+ if (UNEXPECTED(GC_DELREF(in_hash) == 0)) {
+ zend_array_destroy(in_hash);
+ zend_hash_destroy(&out_hash);
+ } else {
+ zend_hash_destroy(in_hash);
+
+ HT_FLAGS(in_hash) = HT_FLAGS(&out_hash);
+ in_hash->nTableSize = out_hash.nTableSize;
+ in_hash->nTableMask = out_hash.nTableMask;
+ in_hash->nNumUsed = out_hash.nNumUsed;
+ in_hash->nNumOfElements = out_hash.nNumOfElements;
+ in_hash->nNextFreeElement = out_hash.nNextFreeElement;
+ in_hash->arData = out_hash.arData;
+ in_hash->pDestructor = out_hash.pDestructor;
+
+ zend_hash_internal_pointer_reset(in_hash);
+ }
}
/* }}} */
|
Isn't it risky to use Also, I tried your implementation but I think there's something wrong with this test case: --TEST--
GH-16649: array_splice UAF with destructor adding element to array
--FILE--
<?php
class C {
function __destruct() {
global $arr;
$arr[] = 0;
}
}
$arr = ["1", new C, "2"];
array_splice($arr, 1, 2);
var_dump($arr);
?>
--EXPECT--
array(3) {
[0]=>
string(1) "1"
[3]=>
int(0)
} It fails with the following output:
If I'm right, the "2" should not appear after the splice? |
The array is being protected by having RC>1. So I think it's fine as it can't "escape".
Yes, because RC1 assertions for arrays are only checked in debug mode. |
Good observation about the test. It happens because the reassignment to in_hash is skipped. There's a couple of different approach we could take, one could be to simply throw an exception when the original array has disappeared; this may be the simplest solution as this can only happen with malicious code. |
Given the convoluted code required to reproduce this case, it seems to me to be a viable solution. |
050c3cf
to
a6bff41
Compare
I implemented your solution @nielsdos. I wonder if the message should avoid mentioning destructors and be less specific in case it happens at an unexpected place yet to be detected? |
a6bff41
to
3eb5aff
Compare
3eb5aff
to
cb3da7d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks reasonable. Thanks!
|
||
if (UNEXPECTED(GC_DELREF(in_hash) == 0)) { | ||
/* Array was completely deallocated during the operation */ | ||
zend_array_destroy(in_hash); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default branch uses zend_hash_destroy(in_hash);
. zend_array_destroy()
seems slightly more optimized (or at least optimized in different ways), but we should stay consistent. Adjusting the default branch would also be possible, but should be measured and done on master.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this approach
cb3da7d
to
b4061bf
Compare
Oh, I now also see why the code uses |
b4061bf
to
1392953
Compare
Yes I just spotted that with the CI. No worries 🙂 |
Fix UAF in array_splice when destructors modify the array during splicing. This PR adds a refresh to array pointers to prevent access to stale memory after triggered reallocations.
PHP doesn't crash anymore with this fix, and the address sanitizer doesn't warn anymore.